격리 수준과 Dirty Read Non-repeatable Read Phantom Read
격리 수준과 Dirty Read Non-repeatable Read Phantom Read
Dirty Read는 미커밋 값을 읽는 현상, Non-repeatable Read는 같은 행의 값이 바뀌는 현상, Phantom Read는 조건에 맞는 행 집합이 바뀌는 현상이다.
목차
- #문제가 되는 상황
- #격리 수준은 동시 실행의 관찰 규칙이다
- #Dirty Read: 미커밋 값을 읽는 현상
- #Non-repeatable Read: 같은 행 값이 바뀌는 현상
- #Phantom Read: 조건에 맞는 행 집합이 바뀌는 현상
- #네 가지 격리 수준 비교
- #MySQL InnoDB에서 snapshot이 만들어지는 시점
- #일반 SELECT와 locking read는 다르다
- #Lost Update와 Write Skew도 살펴본다
- #격리 수준보다 제약과 원자 SQL이 나은 경우
- #재시도 가능한 transaction으로 설계하기
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
transaction A가 재고 10개를 읽는 동안 transaction B가 9개로 수정하고 commit하면, A의 두 번째 SELECT는 10을 볼까 9를 볼까. 격리 수준은 동시에 실행되는 transaction이 서로의 변경을 언제 볼 수 있는지와 DB가 어떤 충돌을 막거나 실패시킬지를 정한다.
격리 수준을 가장 높은 값으로 올리면 모든 업무 오류가 사라지는 것은 아니다. 더 많은 lock 대기나 serialization failure를 만들 수 있고, 애플리케이션이 재시도하지 않으면 사용자는 오히려 오류를 본다. 먼저 방지하려는 읽기·쓰기 현상을 구체적인 실행 순서로 정의해야 한다.
재고·의사 당직·주문 데이터는 동시성 현상을 설명하기 위한 가상 값이다. SQL 세부 동작은 DB 엔진과 버전에 따라 달라질 수 있으므로 운영 DB 공식 문서와 실험으로 확인한다.
격리 수준은 동시 실행의 관찰 규칙이다
transaction을 순서대로 하나씩 실행하면 결과는 이해하기 쉽지만 동시성이 낮다. DB는 MVCC와 lock을 이용해 가능한 한 함께 실행하면서 선택한 격리 규칙을 지킨다.
sequenceDiagram
participant A as Transaction A
participant D as Database
participant B as Transaction B
A->>D: SELECT inventory
B->>D: UPDATE inventory
B->>D: COMMIT
A->>D: 같은 SELECT
Note over A,D: 두 번째 결과는 isolation과 read 종류에 따라 달라짐격리 수준은 단지 SELECT 결과만의 문제가 아니다. UPDATE·DELETE의 search condition, locking read, unique constraint 충돌과 deadlock도 엔진 구현에 따라 영향을 받는다.
Dirty Read: 미커밋 값을 읽는 현상
transaction B가 아직 commit하지 않은 값을 A가 읽는 현상이다.
초기 balance = 10000
Transaction B Transaction A
BEGIN BEGIN
UPDATE balance = 0
SELECT balance → 0
ROLLBACK
A는 실제로 확정되지 않고 사라질 0을 근거로 판단한다. 이를 기반으로 주문을 거절하거나 외부 이벤트를 보내면 B의 rollback 후에도 부수 효과가 남는다.
-- Session B
START TRANSACTION;
UPDATE wallets
SET balance_minor = 0
WHERE user_id = 42;
-- 아직 COMMIT하지 않음
-- Session A, READ UNCOMMITTED에서 dirty value를 볼 수 있는지 실험
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT balance_minor FROM wallets WHERE user_id = 42;
대부분의 일반 업무에서는 dirty read를 허용할 이유가 적다. READ COMMITTED 이상은 미커밋 변경을 읽지 않도록 한다.
Non-repeatable Read: 같은 행 값이 바뀌는 현상
A가 같은 primary key 행을 transaction 안에서 두 번 읽는 사이 B가 update하고 commit해 값이 달라지는 현상이다.
초기 status = pending
Transaction A Transaction B
BEGIN
SELECT status → pending
UPDATE status = paid
COMMIT
SELECT status → paid
READ COMMITTED에서 statement마다 새 snapshot을 사용하면 이런 결과가 가능하다. 첫 결과를 기반으로 계산한 뒤 두 번째 값과 조합하면 한 시점에 존재하지 않은 보고서를 만들 수 있다.
REPEATABLE READ의 consistent read는 transaction 안의 같은 snapshot에서 읽어 일반적으로 같은 행 버전을 유지한다. 하지만 자신의 transaction이 변경한 행은 최신 자신의 변경을 본다.
Phantom Read: 조건에 맞는 행 집합이 바뀌는 현상
같은 행의 값이 아니라 조건에 맞는 행 집합이 달라진다.
Transaction A Transaction B
BEGIN
SELECT WHERE score >= 90
→ rows [1, 2]
INSERT row 3, score=95
COMMIT
SELECT WHERE score >= 90
→ rows [1, 2, 3]
새로 나타난 row 3이 phantom이다. UPDATE로 기존 행이 조건 범위에 들어오거나 DELETE로 사라지는 경우도 집합 변화로 생각할 수 있다.
Snapshot 기반 REPEATABLE READ의 일반 SELECT는 같은 read view를 사용해 두 번째 조회에서도 같은 snapshot 집합을 볼 수 있다. 반면 “범위 안에 새 행을 아무도 insert하지 못하게 잠그고 이후 update하겠다”는 요구는 locking read와 range/gap lock semantics를 확인해야 한다.
SELECT id
FROM reservations
WHERE room_id = :room_id
AND start_at < :end_at
AND end_at > :start_at
FOR UPDATE;
일치 행이 0개일 때 어떤 gap을 lock하는지는 index와 DB 격리 구현에 달려 있다. 예약 중복은 별도 시간 범위 제약 모델이나 Serializable transaction을 검토한다.
네 가지 격리 수준 비교
SQL 표준 용어를 단순화하면 다음과 같다. 실제 DB는 MVCC·lock으로 더 강하거나 다른 동작을 보일 수 있다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 |
| READ COMMITTED | 방지 | 가능 | 가능 |
| REPEATABLE READ | 방지 | 방지 | 표준상 가능 |
| SERIALIZABLE | 방지 | 방지 | 방지 |
이 표만 보고 코드를 설계하면 부족하다. 예를 들어 MySQL InnoDB의 REPEATABLE READ consistent read는 snapshot으로 phantom-like 변화도 보이지 않을 수 있고 locking statement에는 next-key locking이 적용될 수 있다. PostgreSQL의 같은 이름은 다른 구현과 실패 조건을 가진다.
DB 이름, version, storage engine, statement 종류를 함께 문서화한다.
SELECT @@transaction_isolation;
MySQL InnoDB의 기본 isolation이 REPEATABLE READ인지도 deployment 설정에서 확인한다. connection pool이 session 설정을 재사용할 수 있으므로 request별 변경 후 원복 정책이 필요하다.
MySQL InnoDB에서 snapshot이 만들어지는 시점
InnoDB REPEATABLE READ에서 transaction의 모든 consistent read는 첫 consistent read가 설정한 snapshot을 사용한다.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- 일반적으로 이 consistent read가 snapshot을 정하는 지점
SELECT * FROM inventory WHERE product_id = 7;
-- 다른 transaction이 commit해도 같은 snapshot을 읽음
SELECT * FROM inventory WHERE product_id = 7;
COMMIT;
READ COMMITTED에서는 각 consistent read가 새 snapshot을 만든다.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM inventory WHERE product_id = 7; -- snapshot 1
SELECT * FROM inventory WHERE product_id = 7; -- snapshot 2
COMMIT;
transaction 시작 시점과 snapshot 시점을 무조건 같다고 생각하지 않는다. 명시적으로 consistent snapshot을 시작하는 옵션이 있는지, 첫 read 전 다른 SQL이 어떤 영향을 주는지 DB 문서를 확인한다.
일반 SELECT와 locking read는 다르다
일반 SELECT는 consistent nonlocking read일 수 있다.
SELECT available_quantity
FROM inventory
WHERE product_id = 7;
업데이트할 최신 행을 예약하려면 locking read를 사용한다.
SELECT available_quantity
FROM inventory
WHERE product_id = 7
FOR UPDATE;
InnoDB의 FOR UPDATE는 검색한 최신 사용 가능 row와 index record에 update와 비슷한 lock을 설정한다. 다른 transaction이 같은 row를 update하려 하면 commit/rollback까지 기다릴 수 있다. 과거 snapshot 버전 자체를 lock하는 것이 아니다.
consistent read → read view에 보이는 과거 버전 가능, 보통 read lock 없음
locking read → 최신 row를 읽고 lock 획득
같은 transaction에서 먼저 snapshot SELECT로 10을 보고, 이후 FOR UPDATE로 다른 transaction이 commit한 최신 9를 볼 수 있다. 이 차이를 “REPEATABLE READ인데 값이 변했다”는 버그로 오해할 수 있다.
FOR UPDATE는 열린 transaction 안에서 사용해야 의미가 있다. autocommit 한 statement가 끝나면 lock도 곧 해제된다.
Lost Update와 Write Skew도 살펴본다
세 가지 읽기 현상 표만으로 모든 동시성 오류를 설명할 수 없다.
Lost Update
두 transaction이 같은 값을 읽고 각각 계산해 마지막 write가 앞의 변경을 덮어쓴다.
초기 count = 10
A reads 10 → writes 11
B reads 10 → writes 11
의도한 결과 12, 실제 11
DB 안의 원자 증가를 사용한다.
UPDATE counters
SET value = value + 1
WHERE id = :id;
또는 version을 비교하는 optimistic locking을 사용한다.
Write Skew
서로 다른 행을 update하지만 여러 행에 걸친 불변식이 깨질 수 있다. 의사 두 명 중 최소 한 명은 당직이어야 한다고 하자.
A는 B가 당직인 것을 보고 자신을 off로 변경
B는 A가 당직인 것을 보고 자신을 off로 변경
각자 다른 행을 update해 둘 다 commit → 당직 0명
Snapshot isolation 계열에서 발생할 수 있다. Serializable, 불변식을 한 행에 모은 lock, materialized conflict, constraint model을 검토한다.
격리 수준보다 제약과 원자 SQL이 나은 경우
마지막 재고 하나 확보에는 전체 transaction을 SERIALIZABLE로 올리기보다 조건부 update가 단순할 수 있다.
UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE product_id = :product_id
AND available_quantity >= 1;
중복 이메일은 SELECT 후 insert보다 unique constraint가 정확하다.
UNIQUE (email_normalized)
한 endpoint의 규칙 때문에 모든 query 격리 수준을 높이면 lock·abort 비용이 전체 workload에 퍼진다. 가능한 불변식을 DB constraint와 단일 원자 statement로 표현하고, 여러 row의 복잡한 판단에 locking/Serializable을 사용한다.
재시도 가능한 transaction으로 설계하기
높은 격리와 deadlock detection은 위험한 결과를 허용하는 대신 transaction 하나를 실패시킬 수 있다. 오류는 예상 가능한 동시성 제어 결과다.
async function executeSerializable<T>(work: () => Promise<T>): Promise<T> {
return retryWithBackoff(work, {
maxAttempts: 3,
isRetryable: (error) =>
isSerializationFailure(error) || isDeadlock(error),
});
}
재시도하려면 transaction 내부 작업이 멱등해야 한다.
- 외부 이메일·결제 호출을 transaction callback 안에서 직접 수행하지 않는다.
- idempotency key로 같은 command의 중복을 막는다.
- transaction 전체를 새 connection/snapshot에서 다시 실행한다.
- 무한 재시도하지 않고 jitter와 전체 deadline을 둔다.
- retry 횟수와 최종 conflict를 관측한다.
실전 점검 목록
- 막으려는 현상을 두 transaction의 시간 순서로 재현했는가?
- 운영 DB·version·engine의 기본 isolation을 확인했는가?
- 일반 SELECT와 locking read의 가시성 차이를 구분하는가?
- lost update와 write skew 같은 쓰기 이상도 검토했는가?
- unique·check·조건부 UPDATE로 더 작게 해결할 수 있는가?
- range lock을 쓴다면 지원 index와 잠금 범위를 확인했는가?
- deadlock·serialization failure를 transaction 전체 단위로 재시도하는가?
- isolation 변경 후 lock wait·abort·latency를 측정했는가?
Dirty Read는 미커밋 값을 읽는 현상, Non-repeatable Read는 같은 행의 값이 바뀌는 현상, Phantom Read는 조건에 맞는 행 집합이 바뀌는 현상이다.
결론
Dirty Read는 미커밋 값, Non-repeatable Read는 같은 행의 변경, Phantom Read는 조건 결과 집합의 변화를 관찰하는 현상이다. 그러나 같은 격리 이름도 DB·MVCC·locking statement에 따라 실제 동작이 다르며 lost update와 write skew도 별도로 봐야 한다. 제약·조건부 UPDATE로 작은 불변식을 먼저 지키고, 높은 격리에서 발생하는 deadlock과 serialization failure를 안전하게 재시도하도록 transaction 경계를 설계해야 한다.